/*
 *  Copyright 2019-2020 author
 *
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *
 *  http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */

package io.github.jinlongliao.easy.reflection.util;

import io.github.jinlongliao.easy.reflection.exception.ReflectionsException;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.StringTokenizer;
import java.util.function.Predicate;
import java.util.regex.Pattern;

/**
 * Builds include/exclude filters for Reflections.
 * <p>
 * For example:
 * <pre>
 * Predicate&lt;String&gt; filter1 = FilterBuilder.parsePackages("-java, "-javax");
 * Predicate&lt;String&gt; filter2 = new FilterBuilder().include(".*").exclude("java.*");
 * </pre>
 */
public class ScannerFilter implements Predicate<String> {
    private final List<Predicate<String>> chain;
    private static final String COMMA = ",";
    private static final String COMMA_WITH_SPACE = ", ";
    private static final String SPOT = ".";
    private static final char PLUS = '+';
    private static final char MINUS = '-';

    public ScannerFilter() {
        chain = new ArrayList<>();
    }

    private ScannerFilter(final Collection<Predicate<String>> filters) {
        chain = new ArrayList<>(filters);
    }

    /**
     * include a regular expression
     */
    public ScannerFilter include(final String regex) {
        return add(new Include(regex));
    }

    /**
     * exclude a regular expression
     */
    public ScannerFilter exclude(final String regex) {
        add(new Exclude(regex));
        return this;
    }

    /**
     * add a Predicate to the chain of predicates
     */
    public ScannerFilter add(Predicate<String> filter) {
        chain.add(filter);
        return this;
    }

    /**
     * include a package of a given class
     */
    public ScannerFilter includePackage(final Class<?> aClass) {
        return add(new Include(packageNameRegex(aClass)));
    }

    /**
     * exclude a package of a given class
     */
    public ScannerFilter excludePackage(final Class<?> aClass) {
        return add(new Exclude(packageNameRegex(aClass)));
    }

    /**
     * include packages of given prefixes
     */
    public ScannerFilter includePackage(final String... prefixes) {
        for (String prefix : prefixes) {
            add(new Include(prefix(prefix)));
        }
        return this;
    }

    /**
     * exclude a package of a given prefix
     */
    public ScannerFilter excludePackage(final String prefix) {
        return add(new Exclude(prefix(prefix)));
    }

    private static String packageNameRegex(Class<?> aClass) {
        return prefix(aClass.getPackage().getName() + ".");
    }

    public static String prefix(String qualifiedName) {
        return qualifiedName.replace(SPOT, "\\.") + ".*";
    }

    @Override
    public String toString() {
        return StringUtils.append(chain, COMMA_WITH_SPACE);
    }

    @Override
    public boolean test(String regex) {
        boolean accept = chain.isEmpty() || chain.get(0) instanceof Exclude;

        for (Predicate<String> filter : chain) {
            if (accept && filter instanceof Include) {
                continue;
            } //skip if this filter won't change
            if (!accept && filter instanceof Exclude) {
                continue;
            }
            accept = filter.test(regex);
            if (!accept && filter instanceof Exclude) {
                break;
            } //break on first exclusion
        }
        return accept;
    }

    public abstract static class Matcher implements Predicate<String> {
        final Pattern pattern;

        public Matcher(final String regex) {
            pattern = Pattern.compile(regex);
        }

        @Override
        public String toString() {
            return pattern.pattern();
        }
    }

    public static class Include extends Matcher {
        public Include(final String patternString) {
            super(patternString);
        }

        @Override
        public boolean test(final String regex) {
            return pattern.matcher(regex).matches();
        }

        @Override
        public String toString() {
            return "+" + super.toString();
        }
    }

    public static class Exclude extends Matcher {
        public Exclude(final String patternString) {
            super(patternString);
        }

        @Override
        public boolean test(final String regex) {
            return !pattern.matcher(regex).matches();
        }

        @Override
        public String toString() {
            return "-" + super.toString();
        }
    }

    /**
     * Parses a string representation of an include/exclude filter.
     * <p>
     * The given includeExcludeString is a comma separated list of regexes,
     * each starting with either + or - to indicate include/exclude.
     * <p>
     * For example parsePackages("-java\\..*, -javax\\..*, -sun\\..*, -com\\.sun\\..*")
     * or parse("+com\\.myn\\..*,-com\\.myn\\.excluded\\..*").
     * Note that "-java\\..*" will block "java.foo" but not "javax.foo".
     * <p>
     * See also the more useful {@link ScannerFilter#parsePackages(String)} method.
     */
    public static ScannerFilter parse(String includeExcludeString) {
        List<Predicate<String>> filters = new ArrayList<>();
        if (!StringUtils.isBlank(includeExcludeString)) {
            StringTokenizer stringTokenizer = new StringTokenizer(includeExcludeString, COMMA);
            while (stringTokenizer.hasMoreElements()) {
                String trimmed = stringTokenizer.nextToken().trim();
                char prefix = trimmed.charAt(0);
                String pattern = trimmed.substring(1);
                Predicate<String> filter;
                switch (prefix) {
                    case PLUS:
                        filter = new Include(pattern);
                        break;
                    case MINUS:
                        filter = new Exclude(pattern);
                        break;
                    default:
                        throw new ReflectionsException("includeExclude should start with either + or -");
                }
                filters.add(filter);
            }
            return new ScannerFilter(filters);
        } else {
            return new ScannerFilter();
        }
    }

    /**
     * Parses a string representation of an include/exclude filter.
     * <p>
     * The given includeExcludeString is a comma separated list of package name segments,
     * each starting with either + or - to indicate include/exclude.
     * <p>
     * For example parsePackages("-java, -javax, -sun, -com.sun") or parse("+com.myn,-com.myn.excluded").
     * Note that "-java" will block "java.foo" but not "javax.foo".
     * <p>
     * The input strings "-java" and "-java." are equivalent.
     */
    public static ScannerFilter parsePackages(String includeExcludeString) {
        List<Predicate<String>> filters = new ArrayList<>();

        if (!StringUtils.isBlank(includeExcludeString)) {
            for (String string : includeExcludeString.split(",")) {
                String trimmed = string.trim();
                char prefix = trimmed.charAt(0);
                String pattern = trimmed.substring(1);
                if (!pattern.endsWith(".")) {
                    pattern += ".";
                }
                pattern = prefix(pattern);

                Predicate<String> filter;
                switch (prefix) {
                    case PLUS:
                        filter = new Include(pattern);
                        break;
                    case MINUS:
                        filter = new Exclude(pattern);
                        break;
                    default:
                        throw new ReflectionsException("includeExclude should start with either + or -");
                }

                filters.add(filter);
            }

            return new ScannerFilter(filters);
        } else {
            return new ScannerFilter();
        }
    }
}
